snprintf와 sscanf — 문자열 조립과 파싱의 차이
NOTE
printf 가족(출력)과 scanf 가족(입력)의 방향 차이, 버퍼 크기 제한 유무, 반환값이 알려주는 정보의 차이를 정리하고, 커스텀 구분자 파싱을 왜 구조화된 파서로 옮기는 게 나은지 실전 사례로 살펴본다.
0. 배경
둘 다 “포맷 문자열 하나로 여러 값을 한 번에 다룬다”는 점은 같아서 이름도 헷갈리기 쉽지만, 방향이 정반대다.
snprintf: 여러 값 → 문자열로 조립(출력 계열,printf가족)sscanf: 문자열 → 여러 값으로 분해(입력 계열,scanf가족)
실무에서도 두 함수는 정확히 이 대칭 위치에서 자주 쓰인다 — 설정값/로그 메시지를 조립할 땐 snprintf, 고정 포맷의 페이로드를 빠르게 뜯을 땐 sscanf.
1. 함수 시그니처
#include <stdio.h>
int snprintf(char *str, size_t size, const char *format, ...);
int sscanf(const char *str, const char *format, ...);| 파라미터 | snprintf | sscanf |
|---|---|---|
| 첫 인자 | 쓸 목적지 버퍼 | 읽을 원본 문자열 |
| 크기 제한 인자 | size — 명시적으로 넘김 | 없음 — 포맷 안의 필드 폭(%31s 등)으로만 제한 가능 |
... 가변인자 | 값(값 자체를 전달) | 포인터(값을 저장할 변수의 주소를 전달) |
sscanf도 scanf처럼 가변인자에 주소를 받는다는 점을 놓치기 쉽다 — int sst; sscanf(s, "%d", sst);처럼 &를 빼먹으면 컴파일은 되는 경우가 많지만(경고는 뜸) 실행 시 아무 메모리에나 값을 써버리는 미정의 동작이 된다.
2. 방향과 버퍼 안전성
2.1 snprintf — 크기를 직접 넘기니 안전하게 자를 수 있다
char buf[16];
int n = snprintf(buf, sizeof(buf), "%s:%d", name, port);size이상은 절대 쓰지 않는다 — 넘치는 부분은 자르고(truncate), 항상 널 종료 문자를 보장한다(size가 0이 아닌 한).- 오래된
sprintf는 크기 제한이 아예 없어서 버퍼보다 긴 결과가 그대로 스택/힙을 덮어쓸 수 있다 — 그래서 실무에서는sprintf를 거의 안 쓰고snprintf만 쓴다.
2.2 sscanf — %s/%[...]에 폭을 안 주면 그대로 버퍼오버플로우
char tai[32];
sscanf(payload, "%s", tai); /* 위험 — payload가 32자보다 길면 오버플로우 */
sscanf(payload, "%31s", tai); /* 안전 — 최대 31자 + 널 종료 1자 = 32 */
sscanf(payload, "%31[^|]", tai); /* '|' 전까지 최대 31자만 읽음 — 파이프 구분 커스텀 포맷 파서가 자주 쓰는 방식 */sscanf에는 snprintf의 size 같은 “목적지 버퍼 크기” 인자가 아예 없다. 안전하게 쓰려면 포맷 문자열 안에 필드 폭을 사람이 직접 계산해서 박아넣어야 하고, 그 폭이 실제 배열 크기와 어긋나면(리팩토링 중 배열 크기만 바꾸고 포맷 문자열은 안 고치는 실수 등) 오버플로우가 조용히 재발한다 — snprintf(buf, sizeof(buf), ...)처럼 sizeof로 자동 연동할 방법이 없다는 게 근본적인 차이.
3. 반환값이 말해주는 것
| 반환값 의미 | 실패/이상 상황 판단 | |
|---|---|---|
snprintf | 버퍼가 충분히 컸다면 널 종료 문자를 뺴고 실제로 쓰였을 길이(출력 성공 여부와 무관하게 항상 “원했던 길이”) | 반환값 >= size면 **잘렸다(truncate)**는 뜻 — 인코딩 에러 등만 음수 |
sscanf | 성공적으로 값을 읽어서 대입한 변수 개수 | 포맷 자체가 하나도 안 맞으면 매칭 전에 EOF 반환. 그 외엔 “몇 개까지 성공했는지”만 알려줄 뿐, 어느 필드가 왜 실패했는지는 알려주지 않는다 |
char buf[8];
int n = snprintf(buf, sizeof(buf), "%s", "hello world");
/* n == 11 (실제로 쓰고 싶었던 길이) 인데 sizeof(buf) == 8 이므로 잘렸다는 걸 n >= 8 로 알 수 있음 */
int matched = sscanf(payload, "%31[^|]|%d", tai, &sst);
/* matched == 2 여야 둘 다 성공. matched == 1이면 sst 파싱 실패인데,
payload 포맷이 잘못된 건지 sst 자리에 숫자가 아닌 문자가 온 건지는 이 값만으론 구분 불가 */이 비대칭이 실무에서 중요한 이유: snprintf는 “잘렸는지 아닌지”라는 단일하고 명확한 실패 모드만 있는 반면, sscanf는 “포맷 불일치/타입 불일치/구분자 누락/입력 자체가 짧음”이 전부 “매칭 개수 부족”이라는 하나의 신호로 뭉뚱그려져서, 실패했을 때 정확히 뭐가 잘못됐는지 반환값만으로는 재구성하기 어렵다.
4. 실전 사례 — 왜 sscanf 파이프 파싱을 버렸나
요청 바디를 커스텀 구분자 포맷으로 파싱하던 코드가 있었다.
sscanf(payload, "%31[^|]|%d|%15s", field_a, &field_b, field_c);a|b|c를 파이프로 구분한 포맷이었는데, 문제는:
- 필드가 하나 빠지거나 순서가 바뀌어도
matched가 줄어들 뿐, 어느 필드가 문제인지 클라이언트에게 알려줄 만한 정보가 없다(400 에러 메시지를 구체적으로 못 만듦). - 구분자(
|)가 페이로드 값 안에 우연히 섞여 들어가면(예:field_c값에|가 포함) 그 뒤 필드가 전부 밀려서 파싱된다 — 포맷 자체가 값의 내용에 의존적이라 취약함. - 타입 검증이 사실상 없다 —
%d자리에 문자가 오면 그 필드부터 매칭이 멈추지만, 이후 필드들의 상태(초기화됐는지)는 호출자가 일일이 신경 써야 함.
실제 해결은 라이브러리 교체였다 — JSON 파서(json_loads() + json_object_get() 계열 API)로 필드별 존재 여부·타입을 명시적으로 확인하고 실패 지점을 정확히 알 수 있는 구조로 바꿈. sscanf가 “포맷만 맞으면 빠르게 뜯어낼 수 있는” 편리함이 있지만, 구조화된 검증이 필요한 외부 입력 파싱에는 근본적으로 안 맞는 도구였다는 게 이 마이그레이션의 핵심 동기였다.
5. 요약 비교
snprintf | sscanf | |
|---|---|---|
| 계열 | printf 가족 (출력) | scanf 가족 (입력) |
| 방향 | 값들 → 문자열 | 문자열 → 값들 |
| 가변인자로 받는 것 | 값 그 자체 | 값을 저장할 변수의 주소(&) |
| 버퍼 크기 제한 | size 인자로 명시, 자동으로 안전 | 필드 폭(%31s)을 직접 계산해서 넣어야 함 — 안 넣으면 오버플로우 |
| 반환값 | ”원했던 길이” (truncate 여부 판단 가능) | “성공적으로 매칭된 개수” (실패 원인은 알 수 없음) |
| 적합한 용도 | 출력 문자열 조립 전반 | 포맷이 아주 단순·고정적이고 신뢰 가능한 내부 데이터를 빠르게 뜯을 때 |
| 부적합한 용도 | (특별히 없음 — sprintf 대체제로 항상 우선) | 외부/신뢰 못 할 입력의 구조 검증이 필요할 때 → JSON 파서 등 전용 라이브러리가 낫다 |